feat(web): pull the four new design-system primitives into the app (HT-93) - #103
Conversation
…T-93) The Claude Design "Helpthread" project gained four net-new primitives on 2026-07-19, authored in response to a request from app work. They were never pulled down. This adds them to web/src/components/ds/core/: - SplitButton — primary action with an attached caret menu - CommandMenu — searchable saved-replies inserter - SnoozePicker — presets plus a custom calendar and time - CredentialRow / PasskeyList — passkey management, rename and two-step revoke Shared icon glyphs, the focus-ring token, and the date formatters live in primitives-support.jsx rather than being duplicated per component — the design source carried one copy in a single file, and splitting that file must not turn one definition into four. Conversion from the design source was mechanical and every style value is preserved: the source is browser-rendered, so it used React.createElement, module.exports, and local duplicate copies of MenuItem/Button. Those become JSX, ESM exports, and real imports from ./MenuItem and ./Button. The Showcase, its layout scaffolding, and the specimen fixtures are dropped — they are design-project demo code, not app code. One intentional behavioral difference, commented at the site: the design source froze a reference clock (NOW = 2026-07-19 14:30) so specimen times would not drift between renders. The app uses real time via now(); a frozen clock would make SnoozePicker compute "tomorrow" from a past date. Five lint findings are suppressed with reasons rather than auto-fixed, because Biome's fixes would each be silent behavior drift from the design: two noAutofocus (both fields appear only in response to an explicit user action), two noArrayIndexKey (repeating weekday initials in a fixed row, and month-padding cells that have no identity), and one useExhaustiveDependencies (q is the effect's trigger, not a read). Verified: biome check exit 0, tsc exit 0, next build exit 0. None of the existing 16 ds components were modified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughAdds shared design-system primitives and declarations alongside four interactive React components: a searchable command menu, passkey credential controls, a snooze picker, and a split action button. ChangesDesign system components
Estimated code review effort: 4 (Complex) | ~60 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (3)
web/src/components/ds/core/primitives-support.d.ts (1)
2-9: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winPrefer
React.JSX.Elementover the deprecated globalJSX.Elementnamespace.React 19 removed the global
JSXnamespace in favor ofReact.JSX. While@types/react@19still ships a backwards-compatible global for now, it is deprecated and will be removed in a future release. Since this file doesn't importReact, add a type-only import and update the return types.♻️ Proposed refactor
+import type { JSX } from 'react' + export declare const RING: string -export declare function chevron(dir?: 'down' | 'up' | 'left' | 'right', sz?: number): JSX.Element -export declare function IconKey(sz?: number): JSX.Element -export declare function IconSearch(sz?: number): JSX.Element -export declare function IconReply(sz?: number): JSX.Element -export declare function IconClock(sz?: number): JSX.Element -export declare function IconPlus(sz?: number): JSX.Element -export declare function IconPencil(sz?: number): JSX.Element -export declare function IconTrash(sz?: number): JSX.Element +export declare function chevron(dir?: 'down' | 'up' | 'left' | 'right', sz?: number): JSX.Element +export declare function IconKey(sz?: number): JSX.Element +export declare function IconSearch(sz?: number): JSX.Element +export declare function IconReply(sz?: number): JSX.Element +export declare function IconClock(sz?: number): JSX.Element +export declare function IconPlus(sz?: number): JSX.Element +export declare function IconPencil(sz?: number): JSX.Element +export declare function IconTrash(sz?: number): JSX.ElementWith
import type { JSX } from 'react', theJSX.Elementreferences resolve toReact.JSX.Elementwithout needing the global namespace.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@web/src/components/ds/core/primitives-support.d.ts` around lines 2 - 9, Add a type-only React import in the declarations file and update the return types of chevron, IconKey, IconSearch, IconReply, IconClock, IconPlus, IconPencil, and IconTrash to use the imported React JSX element type instead of the deprecated global JSX.Element.web/src/components/ds/core/SplitButton.jsx (1)
99-122: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winCaret toggle lacks
aria-expanded/aria-haspopup.The caret button controls the dropdown (
openstate) but doesn't exposearia-haspopup="menu"/aria-expanded={open}for assistive tech. Since ds/core files must remain verbatim copies of the design source, please confirm whether the prototype already includes these attributes; if not, this should be added upstream first rather than patched locally.As per coding guidelines, "Design-system files under
web/src/components/ds/must remain verbatim copies of the Claude Design prototype/design-system source; improvements must be made upstream in the design project first."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@web/src/components/ds/core/SplitButton.jsx` around lines 99 - 122, Verify whether the upstream design-system prototype’s caret button includes aria-haspopup="menu" and aria-expanded={open}; if present, synchronize this SplitButton caret button with the prototype. If absent, make no local change and instead flag the upstream design source for adding these attributes before updating the verbatim copy.Source: Coding guidelines
web/src/components/ds/core/CredentialRow.jsx (1)
290-290: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
key={c.name}assumes credential names are unique.
Credentialhas no id field, and renaming is user-controlled, so two credentials could end up sharing a name, causing React key collisions/incorrect reconciliation. Consider a stableidonCredentialfor the list key.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@web/src/components/ds/core/CredentialRow.jsx` at line 290, Update the credential list rendering around CredentialRow to use a stable, unique Credential identifier for the React key instead of c.name. Add or propagate an id field on Credential as needed, and pass that identifier to key while preserving the existing row props and ordering.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@web/src/components/ds/core/CommandMenu.d.ts`:
- Line 15: Update the CommandMenu declaration to import the JSX type namespace
from React and retain JSX.Element as the return type of CommandMenu, removing
its reliance on the ambient global JSX namespace.
In `@web/src/components/ds/core/CommandMenu.jsx`:
- Around line 47-49: Update the ArrowDown handling in the CommandMenu highlight
state update to clamp the highlighted index at zero when filtered is empty,
while preserving the existing upper bound for non-empty results. Ensure hi
remains non-negative so later-arriving items can be highlighted and selected.
In `@web/src/components/ds/core/CredentialRow.jsx`:
- Around line 230-291: Update PasskeyList to accept onRename and onRevoke and
forward both callbacks to every rendered CredentialRow, preserving the
credential and new name arguments used by CredentialRow. Add the corresponding
optional callback declarations to PasskeyListProps in CredentialRow.d.ts.
In `@web/src/components/ds/core/SnoozePicker.jsx`:
- Around line 169-174: Update the resolved useMemo in SnoozePicker to validate
that time is non-empty and contains valid hour/minute values before calling
setHours; return a safe non-submittable state when invalid. Ensure the
confirmation display does not use an invalid Date, and disable or guard the
submission path so onSnooze cannot receive an invalid resolved value.
- Around line 5-8: Update laterToday() so it never returns a past timestamp:
after 5 PM, either hide the Later today preset or roll its timestamp forward to
the next valid slot, while preserving the existing 5 PM behavior before that
cutoff.
---
Nitpick comments:
In `@web/src/components/ds/core/CredentialRow.jsx`:
- Line 290: Update the credential list rendering around CredentialRow to use a
stable, unique Credential identifier for the React key instead of c.name. Add or
propagate an id field on Credential as needed, and pass that identifier to key
while preserving the existing row props and ordering.
In `@web/src/components/ds/core/primitives-support.d.ts`:
- Around line 2-9: Add a type-only React import in the declarations file and
update the return types of chevron, IconKey, IconSearch, IconReply, IconClock,
IconPlus, IconPencil, and IconTrash to use the imported React JSX element type
instead of the deprecated global JSX.Element.
In `@web/src/components/ds/core/SplitButton.jsx`:
- Around line 99-122: Verify whether the upstream design-system prototype’s
caret button includes aria-haspopup="menu" and aria-expanded={open}; if present,
synchronize this SplitButton caret button with the prototype. If absent, make no
local change and instead flag the upstream design source for adding these
attributes before updating the verbatim copy.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: b30c6b5f-14f6-40b1-a7be-a6a7afa78542
📒 Files selected for processing (10)
web/src/components/ds/core/CommandMenu.d.tsweb/src/components/ds/core/CommandMenu.jsxweb/src/components/ds/core/CredentialRow.d.tsweb/src/components/ds/core/CredentialRow.jsxweb/src/components/ds/core/SnoozePicker.d.tsweb/src/components/ds/core/SnoozePicker.jsxweb/src/components/ds/core/SplitButton.d.tsweb/src/components/ds/core/SplitButton.jsxweb/src/components/ds/core/primitives-support.d.tsweb/src/components/ds/core/primitives-support.jsx
| width?: number | ||
| initialQuery?: string | ||
| } | ||
| export declare function CommandMenu(props: CommandMenuProps): JSX.Element |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
Does @types/react19.2.4 expose a global JSX namespace, or should library declaration files import JSX fromreactand useJSX.Element?
💡 Result:
No, @types/react 19.2.4 does not expose a global JSX namespace [1][2]. The global JSX namespace was removed in @types/react version 19 to prevent type pollution and conflicts with other UI libraries that also utilize JSX [3][4][1]. For library declaration files and general TypeScript code, you should import the JSX namespace directly from React and use React.JSX.Element instead of the previously global JSX.Element [1][5]. You can access it by importing: import { JSX } from 'react'; // Use as const myElement: JSX.Element =
Citations:
- 1: @types/react - Cannot find namespace 'JSX' DefinitelyTyped/DefinitelyTyped#74625
- 2: https://github.com/facebook/react/blob/ee0855f427832e899767f7659c5289364218ab9e/CHANGELOG.md
- 3: [react] Types for React 19 DefinitelyTyped/DefinitelyTyped#69022
- 4: https://react.dev/blog/2024/04/25/react-19-upgrade-guide
- 5: [react] Add JSX namespace to React namespace DefinitelyTyped/DefinitelyTyped#64464
- 6: [@types/react] does not exist on type 'JSX.IntrinsicElements'.ts after upgrade to react 19 and @types/react 19.0.1 DefinitelyTyped/DefinitelyTyped#71395
🏁 Script executed:
#!/bin/bash
set -euo pipefail
file='web/src/components/ds/core/CommandMenu.d.ts'
echo '--- file outline ---'
wc -l "$file"
echo
cat -n "$file"
echo
echo '--- search for JSX imports/usages in nearby ds core declarations ---'
rg -n --hidden --glob 'web/src/components/ds/core/*.{d.ts,ts,tsx,js,jsx}' 'from '\''react'\''|namespace JSX|JSX\.Element|import type \{ JSX \}' .Repository: Helpthread/helpthread
Length of output: 4929
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- package.json ---'
if [ -f package.json ]; then
cat -n package.json | sed -n '1,240p'
fi
echo
echo '--- tsconfig files mentioning jsx or types ---'
for f in $(git ls-files '*tsconfig*.json' 'package.json' 'pnpm-lock.yaml' 'yarn.lock' 'package-lock.json' 2>/dev/null); do
case "$f" in
*tsconfig*.json|package.json)
echo "### $f"
cat -n "$f" | sed -n '1,240p'
echo
;;
esac
done
echo '--- any JSX namespace declarations in repo ---'
rg -n --hidden --glob '!**/node_modules/**' 'declare\s+namespace\s+JSX|namespace\s+JSX|interface\s+IntrinsicElements' .Repository: Helpthread/helpthread
Length of output: 4990
Reference React’s scoped JSX namespace.
web/src/components/ds/core/CommandMenu.d.ts:15 should not depend on the ambient global JSX namespace. Import type { JSX } from 'react' and keep the return type as JSX.Element so this public declaration stays compatible with React 19 consumers.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/src/components/ds/core/CommandMenu.d.ts` at line 15, Update the
CommandMenu declaration to import the JSX type namespace from React and retain
JSX.Element as the return type of CommandMenu, removing its reliance on the
ambient global JSX namespace.
| if (e.key === 'ArrowDown') { | ||
| e.preventDefault() | ||
| setHi((i) => Math.min(i + 1, filtered.length - 1)) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Keep the highlighted index non-negative for empty results.
Line 49 sets hi to -1 when filtered is empty. If items later arrive without changing q, the menu has no highlighted row and Enter cannot select one.
Proposed fix
- setHi((i) => Math.min(i + 1, filtered.length - 1))
+ setHi((i) => Math.max(0, Math.min(i + 1, filtered.length - 1)))📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (e.key === 'ArrowDown') { | |
| e.preventDefault() | |
| setHi((i) => Math.min(i + 1, filtered.length - 1)) | |
| if (e.key === 'ArrowDown') { | |
| e.preventDefault() | |
| setHi((i) => Math.max(0, Math.min(i + 1, filtered.length - 1))) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/src/components/ds/core/CommandMenu.jsx` around lines 47 - 49, Update the
ArrowDown handling in the CommandMenu highlight state update to clamp the
highlighted index at zero when filtered is empty, while preserving the existing
upper bound for non-empty results. Ensure hi remains non-negative so
later-arriving items can be highlighted and selected.
| export function PasskeyList({ creds = [], empty, onAdd }) { | ||
| const addBtn = ( | ||
| <Button variant="outline" onClick={() => onAdd?.()}> | ||
| <span style={{ display: 'inline-flex', marginRight: -2 }}>{IconPlus(14)}</span> | ||
| Add a passkey | ||
| </Button> | ||
| ) | ||
| return ( | ||
| <div | ||
| style={{ | ||
| border: '1px solid var(--ht-divider)', | ||
| borderRadius: 'var(--ht-radius-md)', | ||
| background: 'var(--ht-surface)', | ||
| overflow: 'hidden', | ||
| }} | ||
| > | ||
| {empty || creds.length === 0 ? ( | ||
| <div style={{ padding: '40px 24px 34px', textAlign: 'center' }}> | ||
| <div | ||
| style={{ | ||
| width: 44, | ||
| height: 44, | ||
| margin: '0 auto 14px', | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| justifyContent: 'center', | ||
| borderRadius: 'var(--ht-radius-md)', | ||
| background: 'var(--ht-surface-2)', | ||
| color: 'var(--ht-ink-dim)', | ||
| }} | ||
| > | ||
| {IconKey(24)} | ||
| </div> | ||
| <div | ||
| style={{ | ||
| fontFamily: 'var(--ht-display)', | ||
| fontSize: 18, | ||
| fontWeight: 600, | ||
| color: 'var(--ht-ink)', | ||
| }} | ||
| > | ||
| No passkeys yet | ||
| </div> | ||
| <div | ||
| style={{ | ||
| margin: '7px auto 18px', | ||
| maxWidth: 320, | ||
| fontSize: 13.5, | ||
| lineHeight: 1.6, | ||
| color: 'var(--ht-ink-muted)', | ||
| }} | ||
| > | ||
| Add a passkey to sign in with your fingerprint, face, or security key — no password to | ||
| remember. | ||
| </div> | ||
| {addBtn} | ||
| </div> | ||
| ) : ( | ||
| <> | ||
| {creds.map((c, i) => ( | ||
| <CredentialRow key={c.name} cred={c} first={i === 0} /> | ||
| ))} |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
PasskeyList never forwards onRename/onRevoke to CredentialRow.
PasskeyList only destructures creds, empty, onAdd. Each rendered CredentialRow therefore always has onRename/onRevoke as undefined, so the rename/revoke UI is fully interactive but has no observable effect — onRename?.(cred, name) and onRevoke?.(cred) are silent no-ops for anyone using the composed PasskeyList (rather than CredentialRow directly). This defeats the primary purpose of the list.
🐛 Proposed fix
-export function PasskeyList({ creds = [], empty, onAdd }) {
+export function PasskeyList({ creds = [], empty, onAdd, onRename, onRevoke }) {
...
{creds.map((c, i) => (
- <CredentialRow key={c.name} cred={c} first={i === 0} />
+ <CredentialRow key={c.name} cred={c} first={i === 0} onRename={onRename} onRevoke={onRevoke} />
))}(Also add onRename?: (cred: Credential, name: string) => void and onRevoke?: (cred: Credential) => void to PasskeyListProps in CredentialRow.d.ts.)
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export function PasskeyList({ creds = [], empty, onAdd }) { | |
| const addBtn = ( | |
| <Button variant="outline" onClick={() => onAdd?.()}> | |
| <span style={{ display: 'inline-flex', marginRight: -2 }}>{IconPlus(14)}</span> | |
| Add a passkey | |
| </Button> | |
| ) | |
| return ( | |
| <div | |
| style={{ | |
| border: '1px solid var(--ht-divider)', | |
| borderRadius: 'var(--ht-radius-md)', | |
| background: 'var(--ht-surface)', | |
| overflow: 'hidden', | |
| }} | |
| > | |
| {empty || creds.length === 0 ? ( | |
| <div style={{ padding: '40px 24px 34px', textAlign: 'center' }}> | |
| <div | |
| style={{ | |
| width: 44, | |
| height: 44, | |
| margin: '0 auto 14px', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| borderRadius: 'var(--ht-radius-md)', | |
| background: 'var(--ht-surface-2)', | |
| color: 'var(--ht-ink-dim)', | |
| }} | |
| > | |
| {IconKey(24)} | |
| </div> | |
| <div | |
| style={{ | |
| fontFamily: 'var(--ht-display)', | |
| fontSize: 18, | |
| fontWeight: 600, | |
| color: 'var(--ht-ink)', | |
| }} | |
| > | |
| No passkeys yet | |
| </div> | |
| <div | |
| style={{ | |
| margin: '7px auto 18px', | |
| maxWidth: 320, | |
| fontSize: 13.5, | |
| lineHeight: 1.6, | |
| color: 'var(--ht-ink-muted)', | |
| }} | |
| > | |
| Add a passkey to sign in with your fingerprint, face, or security key — no password to | |
| remember. | |
| </div> | |
| {addBtn} | |
| </div> | |
| ) : ( | |
| <> | |
| {creds.map((c, i) => ( | |
| <CredentialRow key={c.name} cred={c} first={i === 0} /> | |
| ))} | |
| export function PasskeyList({ creds = [], empty, onAdd, onRename, onRevoke }) { | |
| const addBtn = ( | |
| <Button variant="outline" onClick={() => onAdd?.()}> | |
| <span style={{ display: 'inline-flex', marginRight: -2 }}>{IconPlus(14)}</span> | |
| Add a passkey | |
| </Button> | |
| ) | |
| return ( | |
| <div | |
| style={{ | |
| border: '1px solid var(--ht-divider)', | |
| borderRadius: 'var(--ht-radius-md)', | |
| background: 'var(--ht-surface)', | |
| overflow: 'hidden', | |
| }} | |
| > | |
| {empty || creds.length === 0 ? ( | |
| <div style={{ padding: '40px 24px 34px', textAlign: 'center' }}> | |
| <div | |
| style={{ | |
| width: 44, | |
| height: 44, | |
| margin: '0 auto 14px', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| borderRadius: 'var(--ht-radius-md)', | |
| background: 'var(--ht-surface-2)', | |
| color: 'var(--ht-ink-dim)', | |
| }} | |
| > | |
| {IconKey(24)} | |
| </div> | |
| <div | |
| style={{ | |
| fontFamily: 'var(--ht-display)', | |
| fontSize: 18, | |
| fontWeight: 600, | |
| color: 'var(--ht-ink)', | |
| }} | |
| > | |
| No passkeys yet | |
| </div> | |
| <div | |
| style={{ | |
| margin: '7px auto 18px', | |
| maxWidth: 320, | |
| fontSize: 13.5, | |
| lineHeight: 1.6, | |
| color: 'var(--ht-ink-muted)', | |
| }} | |
| > | |
| Add a passkey to sign in with your fingerprint, face, or security key — no password to | |
| remember. | |
| </div> | |
| {addBtn} | |
| </div> | |
| ) : ( | |
| <> | |
| {creds.map((c, i) => ( | |
| <CredentialRow key={c.name} cred={c} first={i === 0} onRename={onRename} onRevoke={onRevoke} /> | |
| ))} |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/src/components/ds/core/CredentialRow.jsx` around lines 230 - 291, Update
PasskeyList to accept onRename and onRevoke and forward both callbacks to every
rendered CredentialRow, preserving the credential and new name arguments used by
CredentialRow. Add the corresponding optional callback declarations to
PasskeyListProps in CredentialRow.d.ts.
| function laterToday() { | ||
| const d = now() | ||
| d.setHours(17, 0, 0, 0) | ||
| return d |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== file listing ==\n'
git ls-files 'web/src/components/ds/core/SnoozePicker.jsx'
printf '\n== line count ==\n'
wc -l web/src/components/ds/core/SnoozePicker.jsx
printf '\n== relevant excerpt ==\n'
cat -n web/src/components/ds/core/SnoozePicker.jsx | sed -n '1,220p'
printf '\n== search for now() / laterToday / time handling in nearby files ==\n'
rg -n "laterToday|now\(\)|type=\"time\"|setHours\(|SnoozePicker" web/src/components/ds/core web/src/components/ds -g '!**/node_modules/**'Repository: Helpthread/helpthread
Length of output: 12578
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '1,140p' web/src/components/ds/core/SnoozePicker.jsx | nl -baRepository: Helpthread/helpthread
Length of output: 199
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== SnoozePicker time section ==\n'
sed -n '240,300p' web/src/components/ds/core/SnoozePicker.jsx | cat -n
printf '\n== primitives-support clock commentary ==\n'
sed -n '80,120p' web/src/components/ds/core/primitives-support.jsx | cat -n
printf '\n== search for empty-time handling ==\n'
rg -n "time.*''|setTime\(|onChange=.*time|type=\"time\"" web/src/components/ds/core/SnoozePicker.jsxRepository: Helpthread/helpthread
Length of output: 4323
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== SnoozePicker around time input ==\n'
sed -n '248,292p' web/src/components/ds/core/SnoozePicker.jsx | cat -n
printf '\n== primitives-support around now() commentary ==\n'
sed -n '96,118p' web/src/components/ds/core/primitives-support.jsx | cat -nRepository: Helpthread/helpthread
Length of output: 2941
Make Later today future-safe (web/src/components/ds/core/SnoozePicker.jsx:5-8). After 5 PM, laterToday() resolves to a past timestamp; hide the preset once it expires or roll it forward to the next valid slot.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/src/components/ds/core/SnoozePicker.jsx` around lines 5 - 8, Update
laterToday() so it never returns a past timestamp: after 5 PM, either hide the
Later today preset or roll its timestamp forward to the next valid slot, while
preserving the existing 5 PM behavior before that cutoff.
Source: Coding guidelines
| const resolved = React.useMemo(() => { | ||
| const [h, mm] = time.split(':').map(Number) | ||
| const d = new Date(sel) | ||
| d.setHours(h, mm, 0, 0) | ||
| return d | ||
| }, [sel, time]) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Prevent an invalid Date when the time field is cleared.
A native time input can emit ''; destructuring then leaves mm undefined, so setHours() produces an invalid Date. The confirmation text becomes invalid and onSnooze can receive that invalid value. Validate the time before deriving resolved and disable or guard submission until it is valid.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/src/components/ds/core/SnoozePicker.jsx` around lines 169 - 174, Update
the resolved useMemo in SnoozePicker to validate that time is non-empty and
contains valid hour/minute values before calling setHours; return a safe
non-submittable state when invalid. Ensure the confirmation display does not use
an invalid Date, and disable or guard the submission path so onSnooze cannot
receive an invalid resolved value.
CLAUDE.md requires web/src/components/ds/ to be verbatim copies of the Claude Design project's components. They aren't: Biome reformats them on arrival — the repo's javascript.formatter settings (single quotes, semicolons asNeeded) rewrite the design source's double quotes and semicolons, and organizeImports reorders their imports. The code behaves identically; Button.jsx was confirmed semantically identical to the design project's copy, with the entire diff being quotes, semicolons and line wrapping. But that noise makes byte comparison useless as a drift detector — a real design change would be indistinguishable from formatter churn. An override for ds/** already existed, disabling several lint rules that don't suit design-source code. It left the formatter and the import assist enabled, which is what does the rewriting. This turns both off for that path. Verified by construction: a file written in design-source style (double quotes, semicolons) passes `biome check` with exit 0 inside ds/, and fails with a format error outside it. Whole-repo `biome check` stays exit 0. Note this prevents FUTURE mangling; it does not retroactively restore the 16 existing components, which are already reformatted. Making byte comparison actually work requires re-pulling those from the design project — tracked separately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The four new primitives (plus their shared helpers) were promoted into the design project's components/core/ in this ticket. This re-pulls them back down verbatim, so ds/ no longer holds five files that were a conversion rather than a copy — every file under ds/ is now a byte-for-byte copy of its design-project counterpart, which is what CLAUDE.md claims. Round-trip proven byte-exact: the ten re-pulled files were `cmp`'d against the exact bytes uploaded, all identical. Two defects were caught by the normalized-diff pass and fixed upstream first, then re-pulled: - CommandMenu flattened the curly quotes in `Nothing matches “…”` to straight quotes. That is rendered output, so it is a fidelity break, not formatting. - CredentialRow.d.ts narrowed `lastUsed?: Date | null` to `Date`, losing the null the component actually branches on. Three .d.ts signatures deliberately differ from PR #103's: IconKey, IconReply and IconClock take a required `sz`, because unlike IconSearch and IconPlus they carry no default and render wrong without it. The biome.json override gains noAutofocus, useExhaustiveDependencies and noArrayIndexKey. PR #103 suppressed these with inline biome-ignore comments; those are app-lint artifacts and do not belong in verbatim design source, so the suppression moves to the override that already exists for exactly this purpose. The design rationale comments stay in the components. No rule is relaxed outside web/src/components/ds/**. Gates (each on its own exit code): biome check . = 0, npm run typecheck = 0, npm run build = 0.
PR #103 stopped Biome from reformatting web/src/components/ds/ on arrival, but it could not un-mangle the 16 components already there. This does that: each file re-fetched from the Claude Design "Helpthread" project via DesignSync and written byte-for-byte — double quotes, semicolons, original import order and line wrapping restored. Equivalence was proven before overwriting, not assumed. Both the pre-change and post-change trees were formatted through one canonical Biome config and diffed; the diff is empty across all 32 files. So no style value, prop, or branch of logic changed — the whole historical drift really was formatting, and nothing had been hand-edited in the app or moved in the design project. Byte fidelity spot-checked against fresh get_file responses; all 32 files end with a newline and none carry CRLF. Gates (each on its own exit code): biome check . = 0, npm run typecheck = 0, npm run build = 0. Stacked on feat/ht-93-ds-new-primitives — the biome.json override there is a precondition for the biome gate to pass on verbatim files.
The four new primitives (plus their shared helpers) were promoted into the design project's components/core/ in this ticket. This re-pulls them back down verbatim, so ds/ no longer holds five files that were a conversion rather than a copy — every file under ds/ is now a byte-for-byte copy of its design-project counterpart, which is what CLAUDE.md claims. Round-trip proven byte-exact: the ten re-pulled files were `cmp`'d against the exact bytes uploaded, all identical. Two defects were caught by the normalized-diff pass and fixed upstream first, then re-pulled: - CommandMenu flattened the curly quotes in `Nothing matches “…”` to straight quotes. That is rendered output, so it is a fidelity break, not formatting. - CredentialRow.d.ts narrowed `lastUsed?: Date | null` to `Date`, losing the null the component actually branches on. Three .d.ts signatures deliberately differ from PR #103's: IconKey, IconReply and IconClock take a required `sz`, because unlike IconSearch and IconPlus they carry no default and render wrong without it. The biome.json override gains noAutofocus, useExhaustiveDependencies and noArrayIndexKey. PR #103 suppressed these with inline biome-ignore comments; those are app-lint artifacts and do not belong in verbatim design source, so the suppression moves to the override that already exists for exactly this purpose. The design rationale comments stay in the components. No rule is relaxed outside web/src/components/ds/**. Gates (each on its own exit code): biome check . = 0, npm run typecheck = 0, npm run build = 0.
* chore(web): re-pull the 16 design-system components verbatim (HT-94) PR #103 stopped Biome from reformatting web/src/components/ds/ on arrival, but it could not un-mangle the 16 components already there. This does that: each file re-fetched from the Claude Design "Helpthread" project via DesignSync and written byte-for-byte — double quotes, semicolons, original import order and line wrapping restored. Equivalence was proven before overwriting, not assumed. Both the pre-change and post-change trees were formatted through one canonical Biome config and diffed; the diff is empty across all 32 files. So no style value, prop, or branch of logic changed — the whole historical drift really was formatting, and nothing had been hand-edited in the app or moved in the design project. Byte fidelity spot-checked against fresh get_file responses; all 32 files end with a newline and none carry CRLF. Gates (each on its own exit code): biome check . = 0, npm run typecheck = 0, npm run build = 0. Stacked on feat/ht-93-ds-new-primitives — the biome.json override there is a precondition for the biome gate to pass on verbatim files. * docs: UI fidelity is bidirectional, not design-first-only (HT-94) The section said improvements go upstream in the design project first. That has not been the working policy: HT-54's screens were built app-first and approved in the app, and TJ's call (2026-07-20) is that approval in the app is approval — the work flows back up. Documents both directions, why ds/ is excluded from Biome (a formatter pass breaks byte comparison as a drift detector), and that a semantic difference found during a re-pull is a finding to escalate rather than something to quietly resolve. * chore(web): close the loop on the five promoted primitives (HT-94) The four new primitives (plus their shared helpers) were promoted into the design project's components/core/ in this ticket. This re-pulls them back down verbatim, so ds/ no longer holds five files that were a conversion rather than a copy — every file under ds/ is now a byte-for-byte copy of its design-project counterpart, which is what CLAUDE.md claims. Round-trip proven byte-exact: the ten re-pulled files were `cmp`'d against the exact bytes uploaded, all identical. Two defects were caught by the normalized-diff pass and fixed upstream first, then re-pulled: - CommandMenu flattened the curly quotes in `Nothing matches “…”` to straight quotes. That is rendered output, so it is a fidelity break, not formatting. - CredentialRow.d.ts narrowed `lastUsed?: Date | null` to `Date`, losing the null the component actually branches on. Three .d.ts signatures deliberately differ from PR #103's: IconKey, IconReply and IconClock take a required `sz`, because unlike IconSearch and IconPlus they carry no default and render wrong without it. The biome.json override gains noAutofocus, useExhaustiveDependencies and noArrayIndexKey. PR #103 suppressed these with inline biome-ignore comments; those are app-lint artifacts and do not belong in verbatim design source, so the suppression moves to the override that already exists for exactly this purpose. The design rationale comments stay in the components. No rule is relaxed outside web/src/components/ds/**. Gates (each on its own exit code): biome check . = 0, npm run typecheck = 0, npm run build = 0. * docs: record the design project's three-folder component taxonomy (HT-94) TJ approved components/app/ as the home for app-level surface upstream, so the rule it implies gets written down rather than living in one PR thread. core/ is primitives, inbox/ is inbox-specific composition, app/ is whole screens plus the chrome framing them; the test for app/ is that the thing owns a route or wraps all of them. Also records the one place the two sides are deliberately NOT byte-identical: the app/ screens are .tsx here and are converted to presentational .jsx on the way up, unlike ds/ which is a copy. The same taxonomy note is now in the design project's own readme.md.
…dules match the desk (HT-95) Modules are out-of-process and render their own UI, so nothing today makes a module look like the desk it installs into. web/src/components/ds/ and web/src/theme/tokens/ are the right raw material but are AGPL, and a born-proprietary paid module importing them links AGPL code in-process — the case the §7 Module API Exception covers, which is still DRAFT. Proposes publishing the pack under a permissive license as its own package: needs no §7 exception at all, and paid → free is the permitted direction under catalog.md §1. Components are not the moat. Also: theming resolves against the installed desk (white-labeling is a paid item), the pack is generated from ds/ rather than forked (same discipline as CLAUDE.md's UI-fidelity rule, one hop out), and conformance is a marketplace listing requirement since no runtime check can ever enforce it. Docs only. ds/ is owned by HT-93 (PR #103) and HT-94 — untouched here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes HT-93.
Why
The Claude Design "Helpthread" project gained four net-new primitives on 2026-07-19, authored in response to a request from app work. They were never pulled down. They were sitting in
templates/new-primitives/Primitives.jsx, unused.SplitButtonCommandMenusaved_repliestable)SnoozePickersnooze-wakecron)CredentialRow/PasskeyListwebauthn_credentials, HT-75)The conversion
Mechanical, with every style value preserved. The design source is browser-rendered, so it used
React.createElement,module.exports, and local duplicate copies ofMenuItem/Button. Those become JSX, ESM exports, and real imports from./MenuItemand./Button.Dropped: the
Showcase, its layout scaffolding (Section/Panel/StateLabel/Stack/twoUp), and the specimen fixtures. Design-project demo code, not app code.Shared helpers (icon glyphs, the
RINGtoken, date formatters) go inprimitives-support.jsx— the source carried one copy in a single file, and splitting that file must not turn one definition into four.One intentional behavioral difference
The design source froze a reference clock (
NOW = 2026-07-19 14:30) so specimen times wouldn't drift between renders. Correct for a showcase, wrong for the app —SnoozePickerwould compute "tomorrow" from a date in the past. Replaced withnow(), commented at the site.Five lint findings suppressed, not fixed
Biome marks most of these FIXABLE, but each "fix" would be silent drift from the approved design:
noAutofocus×2 — both fields appear only in response to an explicit user action (opening the command menu; clicking Rename). Removing autofocus would strand keyboard users.noArrayIndexKey×2 — weekday initials repeat (two T, two S) in a fixed-length row that never reorders; month-padding cells have no identity of their own.useExhaustiveDependencies×1 —qis the effect's trigger, not a read. Dropping it would stop the highlight resetting when the query changes.Each carries a
biome-ignorewith the reason inline.Verification
biome check web/src/components/ds/core/tsc -p tsconfig.jsonnext buildNone of the existing 16
ds/components were modified —git statusshows only the 10 new files. The formatter pass was run with--linter-enabled=falsespecifically so it couldn't touch them or apply behavior-changing lint fixes.Not in this PR
components/core/upstream so the design project's own library matches. Right now they live intemplates/remotely, so they're staged on both sides.web/src/components/ds/is Biome-formatted, so it no longer byte-matches the design project despiteCLAUDE.mdrequiring verbatim copies.Button.jsxwas confirmed semantically identical — the entire diff is quotes, semicolons, and wrapping. This makes byte comparison useless as a drift detector and wants a deliberate call: excludeds/from Biome, or adopt a normalizing comparison. Filed in HT-93's description.Reviewer attention
The claim worth checking is pixel fidelity — that no style value drifted in the
createElement→ JSX conversion. Worth a spot-check againsttemplates/new-primitives/Primitives.jsxin the design project, particularlySplitButton's seam/border handling andSnoozePicker'sMiniCalendarcell states, which had the densest conditional styling.🤖 Generated with Claude Code
Summary by CodeRabbit